Skip to content

Introduce deprecated annotations#6

Merged
raginpirate merged 4 commits intoUniversal-Commerce-Protocol:mainfrom
raginpirate:raginpirate/introduce-deprecation-annotations
Feb 20, 2026
Merged

Introduce deprecated annotations#6
raginpirate merged 4 commits intoUniversal-Commerce-Protocol:mainfrom
raginpirate:raginpirate/introduce-deprecation-annotations

Conversation

@raginpirate
Copy link
Contributor

@raginpirate raginpirate commented Feb 6, 2026

Description

Adds first-class support for field deprecation with explicit schema transition semantics. This enables schema authors to signal upcoming breaking changes while attempting to preserve backwards compatibility during the transition period.

The schema now adds "deprecated" for transitions to "omit" visibility, and a x-ucp-schema-transition field to express the upcoming changes in an expressive way inside each schema object.

Examples

Required -> Optional

Input:

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "required": ["id", "name"],
  "properties": {
    "id": {
      "type": "string",
      "ucp_request": {
        "update": {
          "from": "required",
          "to": "optional",
          "description": "Will become optional in v2."
        }
      }
    },
    "name": { "type": "string" }
  }
}

Output:

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "properties": {
    "id": {
      "type": "string",
      "x-ucp-schema-transition": {
        "from": "required",
        "to": "optional",
        "description": "Will become optional in v2."
      }
    },
    "name": {
      "type": "string"
    }
  },
  "required": [
    "name"
  ]
}

Required -> Omit

Input:

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "required": ["id", "name"],
  "properties": {
    "id": {
      "type": "string",
      "ucp_request": {
        "update": {
          "from": "required",
          "to": "omit",
          "description": "Legacy id will be removed in v2; use resource_id instead."
        }
      }
    },
    "name": { "type": "string" }
  }
}

Output:

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "properties": {
    "id": {
      "type": "string",
      "x-ucp-schema-transition": {
        "from": "required",
        "to": "omit",
        "description": "Legacy id will be removed in v2; use resource_id instead."
      },
      "deprecated": true
    },
    "name": {
      "type": "string"
    }
  },
  "required": [
    "name"
  ]
}

Motivation

When evolving APIs, transitions like required → optional or required → omit are breaking changes that affect different parties:

  • Loosening (required → optional): Receivers must update to handle absence
  • Tightening (optional → required): Senders must update to always include the field
  • Removal: Both parties need to adapt

Previously, there was no way to express "this field is currently required but will be removed" in a machine-readable way.

Changes

Introduces schema-transitions object which describes the current state, the future state, and a description for the change. Readme describes the expectation for implementation based on each valid transition. Each transitioning visibility is treated as optional while between states, allowing the sender and receiver of each request to properly handle absence without typing errors (someone is always looser than the other).

The concern here: even introducing a schema-transition WILL ALWAYS BE BREAKING because the schema itself will have to loosen in a breaking way for at least one party. Each of businesses or platforms will have to re-parse the output schema state based on the x-ucp tag to identify what fields should or should not be required, optional, or omitted for them truthfully in a version.

Why do we need more than a simple "deprecated" field? Why do we have states?

UCP annotations are symmetrical by default between the sender and receiver; we are introducing integration burdens on just one party for a given request/response (i.e. sender of request, please start ALWAYS sending it as its becoming required, or receiver of this response, stop expecting it even though it was required).

@raginpirate raginpirate requested a review from a team February 6, 2026 14:10
@raginpirate raginpirate force-pushed the raginpirate/introduce-deprecation-annotations branch 2 times, most recently from f846aa8 to dd0006a Compare February 9, 2026 14:58
@igrigorik
Copy link
Contributor

Hmm, clever, but I don't think this is the right pattern.

Deprecation/transitions are typically done as annotation only, without changing validation behavior.

Common three-phase lifecycle:

  1. Announce: add metadata about the upcoming change
  2. Maintain: current contract unchanged; tooling emits warnings from metadata
  3. Execute: explicitly change the contract in a new version/release

If we collapse 2+3, you run into a host of issues...

Announcing intent IS the breaking change

The moment you add a transition annotation to a required field, it stops being required in the resolved schema. There's no way to communicate "this is changing soon" without immediately changing behavior for every consumer. This collapses the announce phase into the execute phase.

Schema contradicts its own documentation

The PR docs say for required → omit: "Senders must still ALWAYS send (receivers require it)." But the resolved schema says the field is optional. The human-readable guidance and the machine-readable contract disagree — a validator will accept payloads without the field, even though the documentation says senders "must still ALWAYS send."

Tightening transitions are invisible

For optional → required, "always optional" means the field stays optional during transition. The schema provides zero validation signal that the contract is getting stricter. Senders can ignore the metadata and break when the annotation finally changes to required.

Comparables..

  • JSON Schema deprecated: true — annotation keyword, never affects validation
  • OpenAPI deprecated: true — evaluator collects metadata, validation unaffected
  • GraphQL @deprecated — shows in introspection, queries still work, resolvers still execute
  • Protobuf [deprecated = true] — compiler warning in codegen, wire behavior unchanged

@igrigorik
Copy link
Contributor

igrigorik commented Feb 12, 2026

All that said, I think the general shape is sound. A few tweaks I'd suggest...

Explicit transition object: signals an upcoming visibility change without altering the current contract. The resolver uses from as the effective visibility and emits the transition as metadata — the schema author explicitly changes to the to value when ready. This follows the universal lifecycle for schema evolution: announce (add metadata) → maintain (current contract unchanged, tooling warns) → execute (change the contract).

"ucp_request": {
  "update": {
    "transition": { "from": "required", "to": "omit", "description": "Use resource_id instead." }
  }
}

Shorthand:

"ucp_request": {
  "transition": { "from": "required", "to": "omit", "description": "Use resource_id instead." }
}
  • from and to are distinct visibility values (omit, optional, required)
  • description is required — forces authors to explain the change

Resolution

The resolver uses from as the visibility and emits transition metadata in the resolved output:

  • x-ucp-transition: { from, to, description } on the property
  • deprecated: true when to is "omit" (standard JSON Schema, tooling-friendly)
  • optionally, ucp-schema / tooling can auto append transition description into the field, e.g...
  {
    "id": {
      "type": "string",
      "description": "The resource identifier.\n\n⚠  Transition: Removing in v2. Use resource_id instead.",
      "x-ucp-transition": { "from": "required", "to": "omit", "description": "Removing in v2..." },
      "deprecated": true
    }
  }

Lifecycle

Announce: behavior unchanged, tooling warns

"ucp_request": {
  "update": {
    "transition": { "from": "required", "to": "omit", "description": "Removing in v2." }
  }
}
→ resolves as required
→ emits x-ucp-transition + deprecated: true

Execute: change when ready

"ucp_request": { "update": "omit" }
→ field removed from resolved schema

@raginpirate
Copy link
Contributor Author

raginpirate commented Feb 12, 2026

@igrigorik thank you for the feedback!

I'm happy to change the PR in this direction, but I want to:

  • make sure we are acknowledging the tradeoff of this direction
  • and I have one blocking question for your proposal.

Tradeoff:
Platforms and Businesses using static type checking for their UCP implementations will run into issues following a transition. Scenario: we transition from required to optional. Today the schema says that type X will always be present, but I know I need to start expecting type X to be missing. As an adopter of UCP, I have to silence linter complaints that I am checking nil on a non-nilable value. The "right" answer would be implementing a custom pre-processor for the json schemas to turn required -> optional based on the x-ucp field. Comparing this to the current proposal, it instead removes any form of linter concern here, but makes us vulnerable to parties expecting strong type safety from the schema and not receiving it. However, I'd argue there are MANY nilable fields that must be non-nilable in UCP based on context learned through specs, so I am opinionated that this proposal was not wrong; the API contract would still remain unbroken while we adjust the type schemas.

Will not block on this tradeoff, I do think your proposal is also a solid perspective and we do not have to optimize for how folks are applying our schemas; your proposal is a valid schema! Just calling out the trouble some folks in the community will face.

Blocking question:
So you've proposed we move from

"ucp_request": {
  "update": "required"
}
-> 
"ucp_request": {
  "update": { "from": "required", "to": "omit", "description": "Removing in v2." }
}

and

"ucp_request": "required"
->
"ucp_request": { "from": "required", "to": "omit", "description": "Removing in v2." }

to

"ucp_request": {
  "update": "required"
}
-> 
"ucp_request": {
  "update": {
    "transition": { "from": "required", "to": "omit", "description": "Removing in v2." }
  }
}

and

"ucp_request": "required"
->
"ucp_request": {
  "transition": { "from": "required", "to": "omit", "description": "Removing in v2." }
}

Could you help me understand the value prop from this change? My initial reaction is just seeing additional nesting that I'm not sure is needed, but please lmk what I'm missing!

@raginpirate raginpirate force-pushed the raginpirate/introduce-deprecation-annotations branch 2 times, most recently from 10095e8 to 6baae26 Compare February 16, 2026 13:27
@raginpirate
Copy link
Contributor Author

raginpirate commented Feb 16, 2026

Thanks for the feedback @igrigorik, we should be good to go now! ♻️ 🚀

And to summarize an async convo we had to unblock my comments above:

  • The current spec for UCP allows for us to push changes to any version of the spec. We want to push this into the already-released 2026-01-23 version, not always push this into a bumped version of the spec. Hence the decision to NOT modify the json types as the same time we introduce the deprecation markers, and thus the tradeoffs I mentioned above are irrelevant. If we change our minds in the future, this solution easily extends 💪
  • using the "transition" wrapper simplified the code a bit because we have a better type value to check against in the shorthand case, which would have been object collision. And its nice to read 😛

Copy link
Contributor

@igrigorik igrigorik left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

overall, lgtm, but let's align behavior across resolve and validate code paths.

@raginpirate raginpirate force-pushed the raginpirate/introduce-deprecation-annotations branch from 06e789f to 5170d8f Compare February 20, 2026 20:15
Copy link
Contributor

@igrigorik igrigorik left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm, modulo outdated comment

@raginpirate raginpirate force-pushed the raginpirate/introduce-deprecation-annotations branch from c5f5831 to f3e3f3d Compare February 20, 2026 20:28
diagnostics.push(Diagnostic {
severity: Severity::Error,
code: "E004".to_string(),
code: "E005".to_string(),

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this intentional? Did something become slightly more-or-less severe?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you're just seeing the artifact of the code-shuffle here Drew!

"invalid {} value "{}": expected omit, required, or optional" is staying as E004.

E005 can occur in two different ways now: either the top-level obj is failing to be a string or transition obj, or the sub-obj failed to be it.

@raginpirate raginpirate merged commit 605baec into Universal-Commerce-Protocol:main Feb 20, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants